"use strict";

(function() {
var prefix = "$p";  // Prefix for keys in realStorage
var live = {};  // Live objects; keys are IDs, values are objects
// Changes since last sync; keys are IDs, values are type of change
var log = {count: 0};
var last_sync = 0;  // Last sync # from server
var process_queue = [];

function isPrivate(attr) {
    return attr[0] === '_'; // __ && attr[1] === '_';
}

var _prefix_memo = {};
function createPrefix(type) {
    /* Create a prefix for usage in realStorage. */
    return _prefix_memo[type] ||
            (_prefix_memo[type]=[prefix, type, ":"].join(''));
}


function find(thing, arr) {
    /* See if something is in an array. */
    for (index in arr) {
        if (arr[index] === thing) {
            return true;
        }
    }

    return false;
}

function logObject(action, ob) {
    /* Log that an action was performed for an object. */
    // TODO Check actions in log and passed in are valid
    var id = ob.__id__;
    var prev_action = log[id];
    var changed = true;  // Assume a change was made

    if (!prev_action) {
        log[id] = action;
    }
    else if (prev_action === "c") {
        if (action === "c") {
            throw new Error("cannot create an object multiple times");
        }
        else if (action === "u") {
            changed = false;
        }
        else {  // "d"
            delete log[id];
        }
    }
    else if (prev_action === "u") {
        if (action === "c") {
            throw new Error("cannot create a pre-existing object");
        }
        else if (action === "u") {
            changed = false;
        }
        else {  // "d"
            log[id] = "d";
        }
    }
    else {  // "d"
        if (action === "c") {
            throw new Error("cannot reuse ID slated for deletion");
        }
        else if (action == "u") {
            throw new Error("cannot update an object that has been deleted");
        }
        else {
            changed = false;
        }
    }

    if (changed) {
        var key = [createPrefix('log'), log.count].join('');
        realStorage.setObject(key, [action, id]);
        log.count += 1;
    }
}

var id_prefix = createPrefix("id");
function isID(id) {
    // TRY Use Dojo approach instead?
    /* Check if an object is actually an ID to another object. */
    return (typeof id) === "string" &&
                id.slice(0, id_prefix.length) === id_prefix;
}

function isolateNode(key, value) {
    /* Replacer for JSON.stringify() */
    if (key !== '' && value !== null && (value instanceof Object) &&
            value.__id__) {
        return value.__id__;
    }
    else {
        return value;
    }
}


function logAndSave(action, ob, check) {
    if (check) {
        var stored_json = realStorage.getItem(ob.__id__);
        var new_json = JSON.stringify(ob, isolateNode);

        if (stored_json === new_json) {
            return;
        }
    }
    saveLocally(ob);
    logObject(action, ob);
}

function changed(ob, check) {
    /* Denote the fact the object has changed. */
    logAndSave("u", ob, check);
}

function saveLocally(ob) {
    /* Update an object's persisted state.

       It is assumed that the object has already been fully processed.  */
    // ___ if (!ob.__id__) {throw new Error("object must be registered");}

    realStorage.setObject(ob.__id__, ob, isolateNode);
}

function loadFromStorage(id) {
    /*
        Load from localStorage.
    */
    var ob = realStorage.getObject(id);
    var attr = null;
    /* __ if (ob.__id__ !== id) {
        throw new Error("IDs don't match");
    }*/
    for (attr in ob) {
        if (!isPrivate(attr)) {
                addAccessor(ob, attr);
            }
    }
    live[ob.__id__] = ob;
    return ob;
}

function load(id) {
    /* Load an object, either from the live objects, localStorage or the
       server. */
    /* __ if (!isID(id)) {
        throw new Error("expect an ID");
    }*/

    if (!live[id]) {
        if (!realStorage.contains(id)) {
            var xhr = new XMLHttpRequest();
            xhr.open('POST', '/_api/gimme', false);
            xhr.send(id);

            if (xhr.status !== 200) {
                throw new Error("getting " + id + " from the server failed");
            }

            var json = xhr.responseText;
            if (!json) {
                throw new Error("N/A");
            }

            var stuff = JSON.parse(json);
            var timestamp = stuff[0];
            var group = stuff[1];
            var obs = stuff[2];

            for (var attr in obs) {
                var ob = JSON.parse(obs[attr]);
                ob.__timestamp__ = timestamp;
                ob.__group__ = group;
                realStorage.setItem(attr, JSON.stringify(ob));
                if (live[attr]) {
                    live[attr].__timestamp__ = timestamp;
                }
            }
            // Don't pull group again.
            addGroup(group, true);
        }

        return loadFromStorage(id);
    }
    else {
        return live[id];
    }
}

function registerObject(ob, id) {
    /* Assign an ID to an object. */
    if (ob.__id__) {
        return ob;
    }

    if (!id) {
        id = [Math.random(), Math.random()].toString();
    }

    // TRY Dojo approach to JSON references
    ob.__id__ = [id_prefix, id].join('');
    if (live[ob.__id__]) {
        throw new Error("duplicate ID: " + id);
    }
    live[ob.__id__] = ob;
    ob.__timestamp__ = 0;
    process_queue.push(ob);

    for (var attr in ob) {
        var subOb = ob[attr];

        if (subOb instanceof Object) {
            registerObject(subOb);
        }
    }

    if (id !== "root") {
        logAndSave("c", ob);
    }
    else {
        logAndSave("u", ob);
    }

    return ob;
}

function addAccessor(ob, attr) {
    /* Replace an attribute on on object w/ accessors and store the attribute's
       value in a "private" attribute, returning the value set on that
       attribute.

       If the attribute specified is a private attribute then simply add the
       accessors needed for it. */
    var ref = ob[attr];
    var real = undefined;

    var getter = function getter() {
        if (real === undefined) {
            if (isID(ref)) {
                real = load(ref);
            }
            else {
                real = ref;
            }
        }

        return real;
    };
    var setter = function setter(value) {
        real = value;
        if (value === undefined) {
            delete this[attr];
        }
        else if (value instanceof Object) {
            registerObject(value);
            ref = value.__id__;
        }
        else {
            ref = value;
        }

        changed(this);
    };


    if (ref instanceof Object) {
        registerObject(ref);
        real = ref;
        ref = ref.__id__;
    }
    else if (!isID(ref)) {
        real = ref;
    }

    delete ob[attr];

    ob.__defineGetter__(attr, getter);
    ob.__defineSetter__(attr, setter);

    return ref;
}

function attrCheck(ob, attr) {
    return (isPrivate(attr) ||
            ob.__lookupGetter__(attr) || (attr === 'prototype') ||
                (ob[attr] instanceof Function));
}

function processObject(ob) {
    /* Add accessors as necessary, returning a boolean based on whether any
       accessors were added. */
    var changed = false;

    for (var attr in ob) {
        /* Don't care about the prototype, hidden attributes, or processed
            attributes */
        if  (attrCheck(ob, attr)) {
            continue;
        }
        else {
            var val = addAccessor(ob, attr);

            if (val instanceof Object) {
                process_queue.push(val);
            }
            changed = true;
        }
    }

    return changed;
}

function updateInPlace(old_state, new_state) {
    /*
       Update an object in-place to match the one being past in.
    */
    var attr = null;

    //console.log("Updating " + JSON.stringify(old_state));
    //console.log("to " + JSON.stringify(new_state));

    /* __ if (!ob) {
        return;
    }*/

    // Clear out pre-existing object
    for (attr in old_state) {
        delete old_state[attr];
    }

    // Copy over new state to the pre-existing object
    for (attr in new_state) {
        old_state[attr] = new_state[attr];
    }

    processObject(old_state);
}


var pulledGroups = [];


function addGroup(group, prepend) {
    /* Add a group to the list of known groups.

      A group is only added if (a) pulledGroups is not null and (b) the group
      is not already contained in the array.

      'prepend' controls whether the group is prepended or appended to the
      array (affects what group is next requested from the server).
    */
    if (pulledGroups == null) {
        return;
    }

    if (group in pulledGroups) {
        return;
    }

    if (prepend) {
        // IE doesn't support unshift().
        pulledGroups.unshift(group);
    }
    else {
        pulledGroups.push(group);
    }
}

function update(id, json, timestamp) {
    // Object deletion
    if (json === null) {
        realStorage.removeItem(id);
        // TODO deal w/ possibility object is currently live
    }
    // Object creation/update
    else {
        var new_state = JSON.parse(json);
        if (timestamp) {
            new_state.__timestamp__ = timestamp;
            realStorage.setObject(id, new_state);
        }
        else {
            realStorage.setItem(id, json);
        }
    }


    // Update live object
    if (live[id]) {
        /* Safari 4 compatibility fix
        for (attr in new_state) {
            if (!isPrivate(attr)) {
                delete new_state[attr];
            }
        }*/

        updateInPlace(live[id], new_state);
        // TODO Call even if object not live?
        if (persist.onUpdate) {
            persist.onUpdate(live[id]);
        }
    }

}


var persist = function(options) {};

persist.assignGroup = function(ob, group) {
    /* Assign the object to a group. */
    ob.__group__ = group;
    changed(ob);
}


persist.coldSync = function(fresh) {
    /* Start synchronizing a cold browser. */
    var group = 0;

    if (!fresh) {
        /* Find the last group fetched. */
        group = pulledGroups[pulledGroups.length - 1];
        while (find(group, pulledGroups)) {
            group += 1;
        }
    }

    var callback = function(group) {
        if (group === null) {
                // No more groups; switch to hot sync.
                pulledGroups = null;
                // TEST Comment out
                setInterval("persist.sync()", 5000);
            }
            else {
                // Pull in the next group.
                // TEST Comment out following line and uncomment other line
                setTimeout('persist.coldSync()', 0);
                //persist.coldSync();
            }
    }

    syncGroup(group, callback, fresh);
}

function syncGroup(group, callback, fresh) {

    if (find(group, pulledGroups)) {
        callback();
    }

    var handler = function() {
        if (this.readyState != this.DONE) {
            return;
        }
        if (this.status != 200) {
            // TODO error
        }
        else {
            var payload = JSON.parse(this.responseText);
            var group = payload[0];
            var objects = payload[1];

            // Store the objects.
            for (id in objects) {
                var json = objects[id];

                //console.log("Received " + id + " as " + json);

                update(id, json);

                if (fresh) {
                    // Find newest timestamp to (eventually) sync to.
                    var ob = JSON.parse(json);

                    if (ob.__timestamp__ > last_sync) {
                        last_sync = ob.__timestamp__;
                    }
                }
            }

            if (group !== null) {
                pulledGroups.push(group);
            }

            callback(group);
        }

    }

    var xhr = new XMLHttpRequest();

    xhr.onreadystatechange = handler;
    // TEST Add 'false' arg
    xhr.open("POST", "/_api/groupies");
    xhr.setRequestHeader("Content-Type", "application/json");
    xhr.send(JSON.stringify(group));
}


persist.groupRequest = function(callback, groups) {
    /* Execute the callback once all the groups have been pulled. */
    if (pulledGroups === null || groups.length === 0) {
        callback();
        return;
    }

    var want = groups.pop();

    var cont = function(group) {
        groups.pop();
        if (groups.length === 0) {
            callback();
        }
        else {
            persist.groupRequest(callback, groups);
        }
    }

    syncGroup(want, cont);
}


persist.localSync = function localSync(timeLimit) {
    /* Synchronize the state of 'root' locally. */
    /* TODO If a private attribute exists but the accessor is missing, then the
           user deleted the accessor. Consider that a deletion. */

    var processed = {};

    for (var id in live) {
        // TODO verify actually an object
        process_queue.push(live[id]);
    }

    var start = (new Date()).getTime();
    var x = 0;

    if (timeLimit === undefined || timeLimit < 0) {
        timeLimit = 0;
    }

    while ((!timeLimit || ((new Date()).getTime() - start < timeLimit)) &&
            (x < process_queue.length)) {
        var obj = process_queue[x];
        if(!processed[obj.__id__] && processObject(obj)) {
            logAndSave("u", obj, true);
            processed[obj.__id__] = true;
        }
        x += 1;
    }
    // TODO Anything left in process_queue needs changed(obj, true) called on it
    process_queue = process_queue.slice(x);
    for (x=0; x < process_queue.length; x+=1) {
        changed(process_queue[x], true);
    }

    return process_queue.length;
};

persist.gc = function gc() {
    //persist.localSync();

    var visit = [persist.root];
    var keys_array = realStorage.keysArray();
    var dead = {};

    for (var x=0; x < keys_array.length; x+=1) {
        if (isID(keys_array[x])) {
            dead[keys_array[x]] = true;
        }
    }

    for (x=0; x < visit.length; x+=1) {
        delete dead[visit[x].__id__];

        for (var attr in visit[x]) {
            var thing = visit[x][attr];

            if (thing instanceof Object) {
                // Assuming all found Objects are registered
                if (dead[thing.__id__]) {
                    visit.push(thing);
                }
            }
        }
    }

    for (var id in dead) {
        // Assuming 'dead' only contains IDs
        logObject("d", {__id__: id});
        delete live[id];
        realStorage.removeItem(id);
    }

    return dead;
};

persist.remoteSync = function remoteSync() {
    /* Sync state remotely. */
    var session = Math.random().toString();
    var xhr = new XMLHttpRequest();
    var send = {};
    var pushed = [];

    pulledGroups = null;  // Once you start "hot" syncing, no going back.

    // TODO Really necessary to send type of update?
    /* TODO Consider local copy a "lease" such that the server is free to
            remove deleted objects after that time and clients syncing will
            need to start from scratch */
    for (var id in log) {
        if (prefix === id.slice(0, prefix.length)) {
            var action = null;
            if (log[id] == "c") {
                action = "create";
            }
            else if (log[id] == "u") {
                action = "update";
            }
            else { // "d"
                action = "delete";
            }
            send[id] = [action, realStorage.getItem(id)];
            pushed.push(id);
        }
    }

    var pending_log_index = log.count;

    log = {count: log.count};

    function handler() {
        if (this.readyState != this.DONE) {
            return;
        }
        else if (this.status != 200) {
            throw new Error("XHR returned " + xhr.status);
        }
        else {
            var log_shift = log.count - pending_log_index;
            // Clear log entries that have been handled
            for (var x=0; x < pending_log_index; x+=1) {
                var key = [createPrefix('log'), x].join('');
                realStorage.removeItem(key);
            }
            // Shift unsent entries to the front of the log
            for (x=0; x < log_shift; x+=1) {
                var old_key = [createPrefix('log'), x + log_shift].join('');
                key = [createPrefix('log'), x].join('');
                var json = realStorage.getItem(old_key);
                if (json === null) {
                    break;
                }
                realStorage.setItem(key, json);
                realStorage.removeItem(old_key);
            }

            log.count = log_shift;

            var response = JSON.parse(xhr.responseText);
            var new_sync = response[0];
            var updates = response[1];
            var conflicts = response[2];

            id = null;
            key = null;
            json = null;
            for (x=0; x < conflicts.length; x+=1) {
                id = conflicts[x];

                if (persist.onConflict) {
                    persist.onConflict(id);
                }
            }

            for (id in updates) {
                json = updates[id];

                update(id, json, new_sync);
            }

            for (x=0; x < pushed.length; x+=1) {
                id = pushed[x];
                if (updates[id] === undefined) {
                    json = realStorage.getItem(id);
                    new_state = JSON.parse(json);
                    new_state.__timestamp__ = new_sync;
                    realStorage.setObject(id, new_state);
                    if (live[id]) {
                        live[id].__timestamp__ = new_sync;
                    }
                }
            }

            last_sync = new_sync;
            realStorage.setObject(createPrefix("last sync"), last_sync);
        }
    };

    xhr.onreadystatechange = handler;

    // TEST Add 'false' arg
    xhr.open("POST", "/_api/whatsnew");
    xhr.setRequestHeader("Content-Type", "application/json");
    /* TODO Need to send session? Couldn't server just filter out objects it
            just mutated from the results? */
    xhr.send(JSON.stringify([send, last_sync, session]));
};


persist.push = function push() {
    // Construct what to send
    var sending = [];
    for (var attr in log) {
        if (attr === "count") {
            continue;
        }
        var json = realStorage.getItem(attr);
        var ob = live[attr];
        if (!ob) {
            ob = JSON.parse(json);
        }
        sending.push([attr, ob.__timestamp__, log[attr], json]);
    }
    // Clear out log
    var pending_log_index = log.count;
    log = {count: log.count};
    // Send data
    var xhr = new XMLHttpRequest();
    xhr.open('POST', '/_api/rewritehistory', false);
    xhr.send(JSON.stringify(sending));
    if (xhr.status !== 200) {
        throw new Error(["push failed", xhr.status].join(': '));
    }
    // Patch log
    var log_shift = log.count - pending_log_index;
    // Clear log entries that have been handled
    for (var x=0; x < pending_log_index; x+=1) {
        var key = [createPrefix('log'), x].join('');
        realStorage.removeItem(key);
    }
    // Shift unsent entries to the front of the log
    for (x=0; x < log_shift; x+=1) {
        var old_key = [createPrefix('log'), x + log_shift].join('');
        key = [createPrefix('log'), x].join('');
        json = realStorage.getItem(old_key);
        if (json === null) {
            break;
        }
        realStorage.setItem(key, json);
        realStorage.removeItem(old_key);
    }
    log.count = log_shift;
    // Handle conflicts
    var data = JSON.parse(xhr.responseText);
    var new_timestamp = data[0];
    var conflicts = data[1];
    for (attr in conflicts) {
        if (persist.onConflict) {
            persist.onConflict(attr);
        }
    }
    // Update timestamps
    for (x=0; x < sending.length; x+=1) {
        var id = sending[x][0];
        ob = realStorage.getObject(id);
        ob.__timestamp__ = new_timestamp;
        realStorage.setObject(ob);
        if (live[id]) {
            live[id].__timestamp__ = new_timestamp;
        }
    }
};

persist.sync = function() {
    /* Sync state both locally and remotely. */
    persist.localSync();
    persist.remoteSync();
};

var last_pull = 0;

persist.pull = function pull() {
    /* Pull in objects in groups by timestamp. */
    var xhr = new XMLHttpRequest();
    //xhr.setRequestHeader("Content-Type", "application/json");
    xhr.open('POST', '/_api/bitofsugar', false);
    xhr.send(JSON.stringify(last_pull));

    if (xhr.status !== 200) {
        throw new Error("pulling failed:" + xhr.status);
    }

    var data = JSON.parse(xhr.responseText);
    last_pull = data[0];
    var obs = data[1];

    for (var guid in obs) {
        // TODO Don't trample mutated objects
        realStorage.setItem(guid, obs[guid]);
    }

    return last_pull >= 0;
};

persist.localReload = function() {
    /* Reload the root from the locally stored state. */
    live = {};
    var root_id = [id_prefix, "root"].join('');

    try {
        persist.root = load(root_id);
    }
    catch (e) {
        persist.root = registerObject({}, 'root');
    }

    if (!realStorage.contains(createPrefix("last sync"))) {
        last_sync = 0;
    }
    else {
        last_sync = realStorage.getObject(createPrefix("last sync"));
    }
};

persist.localReset = function() {
    /* Reset the local state to a pristine one. */
    // TODO Should remove only keys related to persist
    realStorage.clear();

    log = {count: 0};

    live = {};
    process_queue = [];
    last_sync = 0;
    pulledGroups = [];
    persist.localReload();  // No more root, so it's reset cleanly
    //persist.root = registerObject({}, 'root');
};

persist.reset = function() {
    /* Reset both the local and remote state. */
    var xhr = new XMLHttpRequest();

    xhr.onreadystatechange = function() {
        if (this.readyState != this.DONE) {
            return;
        }
        else if (this.status != 200) {
            // TODO signal an error
        }
    };

    xhr.open("GET", "/_api/forgetwhatisaid", false);
    xhr.send();

    persist.localReset();
};


persist.load = load;  // TEST
persist.loadFromStorage = loadFromStorage;  // TEST
persist.getLog = function() {return log;};  // TEST
persist.getLive = function() {return live;}; // TEST

persist.localReload();  // Set persist.root
// function(ob) -> null: called w/ a live object that has been updated
persist.onUpdate = undefined;
window.persist = persist;
// TEST Comment out
if (last_sync > 0) {
    setInterval("persist.sync()", 5000);
}
else {
    persist.coldSync(true);
}

})();
